--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit fd146910b9cded36340767725e5cc0c2c55b3b3c
Parents : ed82234
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T08:08:21-05:00
feat: implement pre-migration backup functionality and enhance backup management with new CLI commands
Changes
8 files changed, 208 insertions(+), 31 deletions(-)
Diff
diff --git a/docs/agents/skills/database-migrations-backups/SKILL.md b/docs/agents/skills/database-migrations-backups/SKILL.md
index bee26be2..26b5cf3f 100644
--- a/docs/agents/skills/database-migrations-backups/SKILL.md
+++ b/docs/agents/skills/database-migrations-backups/SKILL.md
@@ -20,6 +20,7 @@ Bump schema versions correctly, keep backups and snapshots safe, and never confl
- Backups skip `database-backups/` and `snapshots/` so a new zip does not nest itself (`BACKUP_SKIP_DIR_NAMES`).
- Suspicious shrink writes `backup-SUSPICIOUS-*.zip` and skips rotation. Do not treat that as a normal backup.
- Checkpoint WAL before zip snapshots when the live DB is open.
+- Before applying schema upgrades (`current_version` below `LATEST_VERSION`), write `backup-pre-migrate-v*-to-v*.zip` under `database-backups/` unless `MESHCHAT_SKIP_PRE_MIGRATE_BACKUP=1`. Migration aborts if that backup fails.
- Worker-thread connections must share `DatabaseProvider` pragmas (see `landlock-sqlite`).
## Two restore operations
diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md
index d96a1c6f..45a0d5c8 100644
--- a/docs/en/identity-and-security.md
+++ b/docs/en/identity-and-security.md
@@ -87,11 +87,14 @@ Use **Blocked** for specific destination hashes. Combine with sieve filters, mes
## Data backup
-Database backups land in `database-backups/`. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
+Database backups land in `database-backups/`. Before a schema upgrade, MeshChatX writes a `backup-pre-migrate-v*-to-v*.zip` in that folder unless `MESHCHAT_SKIP_PRE_MIGRATE_BACKUP=1`. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
-CLI restore example:
+CLI examples:
```bash
+meshchatx --list-backups
+meshchatx --export-backup /path/to/export.zip
+meshchatx --export-backup backup-20260101-120000.zip /path/to/copy.zip
meshchatx --restore-db /path/to/backup.zip
```
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 313f5697..ccb210a0 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 82de10f9..c318df22 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1472,6 +1472,16 @@ class ReticulumMeshChat:
raise RuntimeError("Database not initialized")
return self.database.backup_database(self.storage_path, backup_path)
+ def list_database_backups(self):
+ if not self.database:
+ raise RuntimeError("Database not initialized")
+ return self.database.list_auto_backups(self.storage_path)
+
+ def export_database_backup(self, name: str, dest_path: str):
+ if not self.database:
+ raise RuntimeError("Database not initialized")
+ return self.database.copy_auto_backup(self.storage_path, name, dest_path)
+
def prepare_for_database_restore(self) -> str | None:
db_path = self.database_path
self._teardown_all_contexts_for_reload()
@@ -10341,6 +10351,8 @@ def main():
"--backup-db",
"--restore-db",
"--restore-from-snapshot",
+ "--list-backups",
+ "--export-backup",
"--help",
"-h",
"--version",
@@ -10471,6 +10483,21 @@ def main():
type=str,
help="Create a database backup zip at the given path and exit.",
)
+ parser.add_argument(
+ "--list-backups",
+ action="store_true",
+ help="List automatic database backups in storage (JSON) and exit.",
+ )
+ parser.add_argument(
+ "--export-backup",
+ nargs="*",
+ default=None,
+ metavar="ARG",
+ help=(
+ "Export a backup and exit. One argument: write a new zip to PATH. "
+ "Two arguments: copy backup NAME from storage to DEST."
+ ),
+ )
parser.add_argument(
"--restore-db",
type=str,
@@ -10699,6 +10726,8 @@ def main():
args.self_check
or args.reset_password
or args.backup_db
+ or args.list_backups
+ or args.export_backup is not None
or args.restore_db
or args.restore_from_snapshot,
)
@@ -10784,6 +10813,29 @@ def main():
print(f"Backup written to {result['path']} ({result['size']} bytes)")
return
+ if args.list_backups:
+ backups = reticulum_meshchat.list_database_backups()
+ print(json.dumps({"backups": backups, "total": len(backups)}, indent=2))
+ return
+
+ if args.export_backup is not None:
+ parts = args.export_backup
+ if len(parts) == 1:
+ result = reticulum_meshchat.backup_database(parts[0])
+ print(f"Backup written to {result['path']} ({result['size']} bytes)")
+ elif len(parts) == 2:
+ result = reticulum_meshchat.export_database_backup(parts[0], parts[1])
+ print(
+ f"Exported {result['name']} to {result['path']} ({result['size']} bytes)",
+ )
+ else:
+ print(
+ "Usage: --export-backup PATH | --export-backup NAME DEST",
+ file=sys.stderr,
+ )
+ sys.exit(2)
+ return
+
if args.restore_db:
result = reticulum_meshchat.restore_database(args.restore_db)
print(f"Restored database from {args.restore_db}")
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 3943a6d5..81d16357 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -25,7 +25,7 @@ from .notification_sounds import NotificationSoundDAO
from .provider import DatabaseProvider
from .ringtones import RingtoneDAO
from .rrc_room_keys import RrcRoomKeysDAO
-from .schema import DatabaseSchema
+from .schema import DatabaseSchema, PreMigrationBackupError
from .sticker_packs import UserStickerPacksDAO
from .stickers import UserStickersDAO
from .telemetry import TelemetryDAO
@@ -97,7 +97,22 @@ class Database:
def initialize(self):
self._tune_sqlite_pragmas()
- self.schema.initialize()
+ self.schema._create_initial_tables()
+ current_version = self.schema.get_current_version()
+ target_version = DatabaseSchema.LATEST_VERSION
+ if 0 < current_version < target_version:
+ from meshchatx.src.env_utils import env_bool
+
+ if not env_bool("MESHCHAT_SKIP_PRE_MIGRATE_BACKUP", False):
+ try:
+ self._backup_pre_migration(current_version, target_version)
+ except Exception as exc:
+ msg = (
+ "Pre-migration backup failed; aborting schema upgrade. "
+ f"Fix disk space or permissions, or set MESHCHAT_SKIP_PRE_MIGRATE_BACKUP=1: {exc}"
+ )
+ raise PreMigrationBackupError(msg) from exc
+ self.schema.migrate(current_version)
def execute_sql(self, query, params=None):
return self.provider.execute(query, params)
@@ -571,6 +586,75 @@ class Database:
"identity_files": len(included),
}
+ def _backup_pre_migration(self, from_version: int, to_version: int) -> dict:
+ storage_path = self._identity_storage_dir()
+ backup_dir = os.path.join(storage_path, "database-backups")
+ os.makedirs(backup_dir, exist_ok=True)
+ timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
+ backup_path = os.path.join(
+ backup_dir,
+ f"backup-pre-migrate-v{from_version}-to-v{to_version}-{timestamp}.zip",
+ )
+ result = self._backup_to_zip(backup_path)
+ print(
+ f"Pre-migration backup written to {result['path']} ({result['size']} bytes)",
+ flush=True,
+ )
+ return result
+
+ def list_auto_backups(self, storage_path: str) -> list[dict]:
+ backup_dir = os.path.join(storage_path, "database-backups")
+ if not os.path.exists(backup_dir):
+ return []
+
+ backups = []
+ for file in os.listdir(backup_dir):
+ if not file.endswith(".zip"):
+ continue
+ full_path = os.path.join(backup_dir, file)
+ stats = os.stat(full_path)
+ backups.append(
+ {
+ "name": file,
+ "path": full_path,
+ "size": stats.st_size,
+ "created_at": datetime.fromtimestamp(
+ stats.st_mtime,
+ UTC,
+ ).isoformat(),
+ },
+ )
+ return sorted(backups, key=lambda row: row["created_at"], reverse=True)
+
+ def copy_auto_backup(self, storage_path: str, filename: str, dest_path: str) -> dict:
+ from meshchatx.src.path_utils import safe_path_under_dir
+
+ if not isinstance(filename, str) or not filename or "\x00" in filename:
+ msg = "Invalid backup name"
+ raise ValueError(msg)
+ normalized = filename.replace("\\", "/")
+ if normalized != os.path.basename(normalized) or ".." in normalized:
+ msg = "Invalid backup name"
+ raise ValueError(msg)
+
+ backup_dir = os.path.join(storage_path, "database-backups")
+ name = filename if filename.endswith(".zip") else f"{filename}.zip"
+ src = safe_path_under_dir(backup_dir, name)
+ if not src or not os.path.isfile(src):
+ msg = f"Backup not found: {name}"
+ raise FileNotFoundError(msg)
+
+ dest_parent = os.path.dirname(os.path.abspath(dest_path))
+ if dest_parent:
+ os.makedirs(dest_parent, exist_ok=True)
+ shutil.copy2(src, dest_path)
+ return {
+ "name": name,
+ "source": src,
+ "path": os.path.abspath(dest_path),
+ "size": os.path.getsize(dest_path),
+ }
+
def backup_database(
self,
storage_path,
diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 36e2d881..0da90816 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -11,6 +11,10 @@ class DatabaseMigrationError(RuntimeError):
pass
+class PreMigrationBackupError(RuntimeError):
+ pass
+
+
def _validate_identifier(name: str, label: str = "identifier") -> str:
if not _IDENTIFIER_RE.match(name):
msg = f"Invalid SQL {label}: {name!r}"
@@ -142,6 +146,9 @@ class DatabaseSchema:
self._ensure_column(table_name, column_name, column_type)
+ def get_current_version(self) -> int:
+ return self._get_current_version()
+
def _get_current_version(self):
try:
row = self.provider.fetchone(
diff --git a/meshchatx/src/backend/http/routes/database.py b/meshchatx/src/backend/http/routes/database.py
index ac115db3..260495be 100644
--- a/meshchatx/src/backend/http/routes/database.py
+++ b/meshchatx/src/backend/http/routes/database.py
@@ -264,33 +264,7 @@ def register_database_routes(routes, app):
try:
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
- backup_dir = os.path.join(app.storage_path, "database-backups")
- if not os.path.exists(backup_dir):
- return web.json_response(
- {"backups": [], "total": 0, "limit": limit, "offset": offset},
- )
-
- backups = []
- for file in os.listdir(backup_dir):
- if file.endswith(".zip"):
- full_path = os.path.join(backup_dir, file)
- stats = os.stat(full_path)
- backups.append(
- {
- "name": file,
- "path": full_path,
- "size": stats.st_size,
- "created_at": datetime.fromtimestamp(
- stats.st_mtime,
- UTC,
- ).isoformat(),
- },
- )
- sorted_backups = sorted(
- backups,
- key=lambda x: x["created_at"],
- reverse=True,
- )
+ sorted_backups = app.database.list_auto_backups(app.storage_path)
total = len(sorted_backups)
paginated_backups = sorted_backups[offset : offset + limit]
return web.json_response(
diff --git a/tests/backend/test_database_snapshots.py b/tests/backend/test_database_snapshots.py
index 1481d23c..dc098643 100644
--- a/tests/backend/test_database_snapshots.py
+++ b/tests/backend/test_database_snapshots.py
@@ -449,3 +449,59 @@ def test_restore_includes_identity_rrc_and_history(temp_dir):
reopened.close_all()
assert row is not None
assert row["value"] == "before-backup"
+
+
+def test_pre_migration_backup_written_before_schema_upgrade(temp_dir):
+ from meshchatx.src.backend.database.schema import DatabaseSchema
+
+ db_path = os.path.join(temp_dir, "test.db")
+ db = Database(db_path)
+ db.initialize()
+ db.close_all()
+
+ prior = DatabaseSchema.LATEST_VERSION - 1
+ if prior < 1:
+ pytest.skip("No prior schema version to simulate")
+
+ provider = DatabaseProvider(db_path)
+ provider.execute(
+ "UPDATE config SET value = ? WHERE key = ?",
+ (str(prior), "database_version"),
+ )
+ provider.close_all()
+
+ upgraded = Database(db_path)
+ upgraded.initialize()
+ backups = upgraded.list_auto_backups(temp_dir)
+ upgraded.close_all()
+
+ assert any("backup-pre-migrate" in row["name"] for row in backups)
+
+
+def test_pre_migration_backup_skipped_with_env(temp_dir, monkeypatch):
+ from meshchatx.src.backend.database.schema import DatabaseSchema
+
+ monkeypatch.setenv("MESHCHAT_SKIP_PRE_MIGRATE_BACKUP", "1")
+
+ db_path = os.path.join(temp_dir, "test.db")
+ db = Database(db_path)
+ db.initialize()
+ db.close_all()
+
+ prior = DatabaseSchema.LATEST_VERSION - 1
+ if prior < 1:
+ pytest.skip("No prior schema version to simulate")
+
+ provider = DatabaseProvider(db_path)
+ provider.execute(
+ "UPDATE config SET value = ? WHERE key = ?",
+ (str(prior), "database_version"),
+ )
+ provider.close_all()
+
+ upgraded = Database(db_path)
+ upgraded.initialize()
+ backups = upgraded.list_auto_backups(temp_dir)
+ upgraded.close_all()
+
+ assert not any("backup-pre-migrate" in row["name"] for row in backups)
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────